Skip to content

Fix 500s and empty results from binary fields in Calcite pushdowns - #5767

Open
cnoramut wants to merge 1 commit into
opensearch-project:mainfrom
cnoramut:fix/binary-pushdown-500
Open

cnoramut wants to merge 1 commit into
opensearch-project:mainfrom
cnoramut:fix/binary-pushdown-500

Conversation

@cnoramut

Copy link
Copy Markdown
Contributor

Description

source=idx | sort bin on a binary field returns a 500 whose reason is a generic Failed to fetch data from the index. The real cause is visible only in details, where it reads IllegalArgumentException[Can't load fielddata on [bin] because fielddata is unsupported on fields of type [binary]]. Nine commands fail this way. A tenth, where isnotnull(bin), returns 200 with zero rows against documents that all have the field populated.

A binary field has neither fielddata nor doc values, so OpenSearch cannot bucket or sort on it. The plan-time guards ask whether a type is atomic, not whether it is aggregatable. Binary("binary", ExprCoreType.UNKNOWN) reads as atomic, passes every guard, and reaches the shard. geo_point becomes GEOMETRY, fails the same test, and is already rejected as a 400 by #5751. binary is not.

The filter case fails differently, and silently. existsQuery(bin) is valid DSL, but BinaryFieldMapper indexes nothing when doc_values is false, not even a _field_names entry, so exists has no term to match and the shard honestly reports zero hits for a correctly-formed query.

This refuses a binary reference where a field reference resolves, so each pushdown declines and Calcite keeps an un-pushed plan that returns correct results.

  • PredicateAnalyzer.NamedFieldExpression.getReference() and getReferenceForTermQuery(), the seam every affected family already routes through. The filter path re-analyzes the predicate as a _source script and stays pushed down, while the aggregate and sort paths decline the planner rule.
  • AbstractCalciteIndexScan, the two field-sort sites. The script-sort branch beside them needs no guard, since it reads from _source.
  • AggregateAnalyzer, the dedup sort hint, which carries a raw field name that never reaches the accessors above.
  • RexStandardizer, route a binary field to _source rather than doc values, which is what makes the filter redirect work.

Refusing in the accessors rather than in the NamedFieldExpression constructors is deliberate. A top_hits fetch field only needs getRootName(), and BinaryFieldMapper.BinaryFieldType.valueFetcher returns SourceValueFetcher.identity, so the fields API serves a binary field from _source and that request shape was always valid. A constructor-level refusal would decline dedup on any index whose mapping merely contains a binary field, even when the query never names it, costing a pushdown that works correctly today, 0.0075s pushed down against 0.254s declined over 50000 documents. The last case in the test file pins this.

Before

"reason":  "Failed to fetch data from the index: the background task failed or interrupted."
"details": "... IllegalArgumentException[Can't load fielddata on [bin] because fielddata is
            unsupported on fields of type [binary]. Use doc values instead.]"
"status":  500

After, the same query, HTTP 200

"schema":   [{"name": "host", "type": "string"}, {"name": "bin", "type": "binary"}]
"datarows": [["host-a", "Y210"], ["host-a", "Y211"], ["host-b", "Y212"], ["host-b", "Y213"]]
"total":    4

Behaviour on a binary field, measured on a live cluster before and after.

Query Before After
sort bin 500, fielddata 200, 4 rows in order
stats count() by bin 500 200, 4 buckets
stats max(bin) 500 200, the real maximum
top 2 bin, rare 2 bin, dedup bin 500 200
timechart span=1m count() by bin, chart count() over m by bin 500 200
xyseries m bin IN ('x','y') c 500 200
sort bin | dedup m 500 200
where isnotnull(bin) 200, zero rows 200, all 4 rows
sort latency | fields bin 200 200, unchanged

One behaviour change beyond the reported symptom. The RexStandardizer change applies to every script context, so a pushed-down script referencing a binary field previously read null from doc values and now reads the real base64 value. eval x = concat(bin, 'a') returns a value where it used to return null.

That also moves where a bad script fails. A comparison against a binary field is now compiled on the shard against the real value, so where bin = 'zzz' returns a QueryShardException for a script it cannot compile rather than the earlier fielddata error. Nothing regresses, but the error text changes.

graphLookup on a binary edge field is the one caller these accessors do not protect, and it was already broken. CalciteEnumerableGraphLookup.queryLookupTable resolves the edge field at execution time outside any decline path, so the refusal escapes as a 500 instead of declining a rule. It returned a 500 before this change as well, since the terms query it emitted on a binary field fails at the shard, so the only difference is that the message now names the field. Verified on a live cluster, a keyword edge returns rows and a binary edge returns 500 either side of the change. graphLookup is marked experimental and a base64 blob is not a plausible graph edge, so this is recorded, not fixed.

An alias field whose path is a binary field looks unguarded and is not. instanceof OpenSearchBinaryType is false for the OpenSearchAliasType such a mapping produces, but Calcite resolves the alias at plan time to the base field's input ref, so the explain shows payload_alias=[$1] where $1 is payload, and the name reaching the guard is never the alias. Verified on a live cluster.

Out of scope, noted in the issue. Comparing a binary field against a string still fails, and not only for =. where bin = 'zzz' and where bin != 'zzz' report Cannot cast "java.lang.String" to "org.apache.calcite.avatica.util.ByteString", where bin > 'a' reports no applicable SqlFunctions.gt overload, and where match(bin, 'zzz') cannot work at all against a field OpenSearch does not index. Every one of those fails with pushdown disabled too, so declining the pushdown exposes the coercion gap rather than causing it, and the family belongs with #5753. What this fixes on the filter side is the null predicates, isnotnull(bin) returning all populated rows instead of none and isnull(bin) returning none, which is what the test file covers.

Also, stats count() by bin declines rather than taking the scripted _source route AggregateAnalyzer already uses for a text field with no .keyword. That costs a full scan, measured at 0.42s against 0.17s for a keyword group key over the same 50000 documents, so roughly 2.5x on a different cardinality rather than a like-for-like comparison. Worth it against a 500, but worth reclaiming. A blanket null return is not the way, because it would make max(bin) build a top_hits with no sort and return an arbitrary document, so keeping that pushdown needs the value-source site separated from the sort sites.

Related Issues

Resolves #5757

Testing

  • integ-test/src/yamlRestTest/resources/rest-api-spec/test/issues/5757.yml, 19 cases over HTTP against a real binary mapping, covering all three pushdown families plus the xyseries aggregate-filter-argument route and the dedup sort hint. 18 assert answers. The last asserts a plan, because the accessor-versus-constructor placement above returns identical correct rows either way and is invisible to an answer assertion.
  • Both new guards were confirmed to fail with their fix reverted and to pass with it restored. Removing the AggregateAnalyzer dedup-hint check fails only the dedup sort-hint case. Moving the refusal into the constructors fails only the plan case.
  • RelJsonSerializerTest.testSerializeAndDeserializeUDT changes one expected value, the script source for the binary field, from DOC_VALUE to SOURCE. That flip is the unit-level assertion of the RexStandardizer change and of the behaviour change noted above, so it is the point of the edit rather than a fixup around it.
Suite Result
:opensearch:test 1711 tests, 0 failures, 3 skipped
:integ-test:yamlRestTest -Dtests.rest.suite=issues/5757 19/19 pass, 0 skipped
CalciteExplainIT, pushdown and no-pushdown variants 532 tests, 0 failures, 84 skipped
spotlessCheck clean

Verified against a local 3.9.0-SNAPSHOT tarball, single node and single shard, with plugins.calcite.enabled and plugins.calcite.pushdown.enabled both true. Not tested multi-shard or with security enabled.

Check List

  • New functionality includes testing.
  • New functionality has been documented.
  • New functionality has javadoc added.
  • New functionality has a user manual doc added.
  • New PPL command checklist all confirmed.
  • API changes companion pull request created.
  • Commits are signed per the DCO using --signoff or -s.
  • Public documentation issue/PR created.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

A binary field has neither fielddata nor doc values, but the plan-time
guards test whether a type is atomic rather than aggregatable, so
OpenSearchBinaryType passes them and reaches the shard. Nine PPL commands
fail there with a 500, and where isnotnull returns 200 with zero rows
because BinaryFieldMapper writes no _field_names entry for exists.

Refuse a binary reference in NamedFieldExpression.getReference and
getReferenceForTermQuery, in the two field-sort sites of
AbstractCalciteIndexScan, and in the AggregateAnalyzer dedup sort hint.
Those pushdowns decline and Calcite returns correct rows un-pushed, while
the filter path re-analyzes as a _source script. RexStandardizer routes a
binary field to _source, so a pushed-down script over one now reads its
real base64 value instead of null from doc values.

Signed-off-by: Chayanin Noramuttha <cnoramut@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ Recommended focus areas for review

Possible Issue

The guard checks helper.fieldTypes.get(key.field()) but does not handle the case where the field is absent from the map. If key.field() returns a name not in fieldTypes, get() returns null, and instanceof OpenSearchBinaryType evaluates to false, allowing the pushdown to proceed. The shard will then reject the request. This occurs when a dedup sort key references a field that does not exist in the index mapping.

if (helper.fieldTypes.get(key.field()) instanceof OpenSearchBinaryType) {
  throw new AggregateAnalyzer.AggregateAnalyzerException(
      String.format("Cannot push down a dedup sort on binary field [%s]", key.field()));
}
Possible Issue

The guard checks osIndex.getFieldTypes().get(fieldName) but does not handle the case where fieldName is absent from the map. If get() returns null, instanceof OpenSearchBinaryType evaluates to false, allowing the pushdown to proceed. The shard will then reject the request. This occurs when a sort key references a field that does not exist in the index mapping.

if (fieldType instanceof OpenSearchBinaryType) {
  if (LOG.isDebugEnabled()) {
    LOG.debug("Cannot pushdown the sort on binary field {}", fieldName);
  }
  return null;
}
Possible Issue

The guard checks osIndex.getFieldTypes().get(digest.getFieldName()) but does not handle the case where the field name is absent from the map. If get() returns null, instanceof OpenSearchBinaryType evaluates to false, allowing the pushdown to proceed. The shard will then reject the request. This occurs when a sort expression references a field that does not exist in the index mapping.

if (osIndex.getFieldTypes().get(digest.getFieldName()) instanceof OpenSearchBinaryType) {
  if (LOG.isDebugEnabled()) {
    LOG.debug("Cannot pushdown the sort on binary field {}", digest.getFieldName());
  }
  return null;
}

@github-actions

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Add null check for field type

The field type lookup may return null if the field doesn't exist in the mapping. Add
a null check before the instanceof check to prevent NullPointerException when
processing dedup sort keys on non-existent fields.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/AggregateAnalyzer.java [632-635]

-if (helper.fieldTypes.get(key.field()) instanceof OpenSearchBinaryType) {
+ExprType fieldType = helper.fieldTypes.get(key.field());
+if (fieldType instanceof OpenSearchBinaryType) {
   throw new AggregateAnalyzer.AggregateAnalyzerException(
       String.format("Cannot push down a dedup sort on binary field [%s]", key.field()));
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that helper.fieldTypes.get(key.field()) could return null if the field doesn't exist in the mapping, which would cause the instanceof check to safely return false but leaves the code vulnerable to potential issues. However, the instanceof operator already handles null safely (returns false), so while extracting to a variable improves code clarity and enables future null handling, it doesn't prevent an actual NullPointerException. The suggestion is valid for defensive programming and code maintainability.

Medium

@codecov

codecov Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 16.66667% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 62.82%. Comparing base (6116c33) to head (8223f3e).
⚠️ Report is 1657 commits behind head on main.

Files with missing lines Patch % Lines
...nsearch/storage/scan/AbstractCalciteIndexScan.java 0.00% 8 Missing ⚠️
...arch/sql/opensearch/request/AggregateAnalyzer.java 0.00% 3 Missing ⚠️
...arch/sql/opensearch/request/PredicateAnalyzer.java 50.00% 2 Missing and 1 partial ⚠️
.../sql/opensearch/storage/serde/RexStandardizer.java 0.00% 0 Missing and 1 partial ⚠️

❌ Your project check has failed because the head coverage (62.82%) is below the target coverage (99.00%). You can increase the head coverage or adjust the target coverage.

❗ There is a different number of reports uploaded between BASE (6116c33) and HEAD (8223f3e). Click for more details.

HEAD has 5 uploads less than BASE
Flag BASE (6116c33) HEAD (8223f3e)
sql-engine 6 1
Additional details and impacted files
@@              Coverage Diff              @@
##               main    #5767       +/-   ##
=============================================
- Coverage     98.40%   62.82%   -35.58%     
- Complexity     2746     8778     +6032     
=============================================
  Files           266      937      +671     
  Lines          6758    40135    +33377     
  Branches        426     4522     +4096     
=============================================
+ Hits           6650    25216    +18566     
- Misses          107    14118    +14011     
- Partials          1      801      +800     
Flag Coverage Δ
sql-engine 62.82% <16.66%> (-35.58%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Binary fields return 500 or silently wrong results when a pushdown references them

1 participant